fix(spec): permissionForm stops teaching the Profile concept ADR-0090 D2 removed - #16941
Conversation
… D2 removed The form's `Identity` section description — the half that ships, and ships translated — read "Permission Sets stack on top of a Profile to grant additional access. Profiles are the base set assigned 1:1 to each user." That is the model ADR-0090 D2 retired: it deleted `isProfile` from `PermissionSetSchema` (removed, not deprecated), leaving permission sets as the only capability container. The description now states the v2 model instead, in all four locales it shipped in. The same file's docstring claimed the form serves a `profile` metadata kind alongside `permission`, and carried a sentence with no subject — "The only flags are minimal (ADR-0090 D2 removed the Profile concept) so admins can see and toggle it explicitly" — that named no flag and whose `it` referred to nothing. Both are replaced with statements the tree enforces. None of the three translated leaves had a recorded source hash, so they were legacy-trusted: changing the English alone would have left three languages teaching the retired concept under a green build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 2 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
ACCEPT —
|
origin/main |
PR head | |
|---|---|---|
:10 / :9 |
"Used for both permission … and profile … (ADR-0090 D2 removed the Profile concept)" |
"There is no Profile concept: ADR-0090 D2 removed it (isProfile deleted, not deprecated)" |
:24 → shipped |
"Permission Sets stack on top of a Profile … Profiles are the base set assigned 1:1 to each user." | "Permission sets are the only capability container: a user gets the union of every set they hold, so sets only ever add access." |
⭐ Both remaining mentions are now tombstone language. And the new docstring names the artefacts that make it true — METADATA_FORM_REGISTRY has no profile key, MetadataTypeSchema admits no profile kind, PermissionSetSchema answers with a retirement tombstone — rather than promising a future. That is the "⛔ do not write another until X lands" discipline met.
The second defect is repaired too, and needed no ruling: the subject-less "The only flags are minimal … so admins can see and toggle it" became "The form surfaces no flag: isDefault (ADR-0090 D5) is the schema's only boolean and it records a boot-time binding hint, not a grant" — with isDefault measured as the only top-level boolean.
⭐ All four locales moved, and no gate would have caught it if they had not
| reading | value |
|---|---|
| locale bundles in the diff | 4 — en, zh-CN, ja-JP, es-ES (verified in the file list by this seat) |
retired sentence on a - line in each |
yes, in all four (plus a fifth - for the zh-CN group label) |
metadataForms.permission.* is 0 entries in each of the three tables, against 158 / 191 / 191 metadataForms.* entries overall (that left column is the positive control). Legacy-trusted leaves are never reported stale and withSourceFallback never substitutes ⇒ an English-only fix would have shipped three languages teaching the retired concept under a fully green build. The gate that looks like it covers this does not.
⚠️ A correction to this seat's own reasoning on a sibling PR
The dev measured the changeset question in two halves, and they disagree:
- Half 1 — changed path vs
files[]: no changed path matches, for either package.permission.form.tsis*.form.ts, not*.zod.ts;platform-objectspublishes nosrc/at all. ⇒ this half alone saysskip-changeset, and that is wrong. - Half 2 — the text in the published artefact: the strings are in the shipped
distof both packages, in every locale — measured against the unpacked npm tarballs of@objectstack/spec@17.3.0and@objectstack/platform-objects@17.3.0, 6 dist files each, with untouched-sibling positive controls also at 6.
⇒ A real changeset, patch on both. ⭐ And the dev names the consequence precisely: "This is the half that makes two sibling PRs disagree on the same question."
That lands on me. My ACCEPT on PR #16938 justified its skip-changeset with half 1 only — "that one edits packages/spec/src/**/*.zod.ts, which files[] publishes; this one edits content/docs/**, which no package ships." The conclusion there survives — re-measured now, no package's files[] names content/docs (scanned every packages/*/package.json; control: @objectstack/spec's files[] reads back in full) — but the method I wrote down as "a measurable reason" is the half that can give a wrong answer. ⛔ Anyone using that comment as precedent should use the two-half method instead.
Other readings this seat took
| reading | value | instrument |
|---|---|---|
| diff shape | 6 files, +33 / −10 | git diff --stat |
| governed surface | 0 of 6 | check-governed-merges.mjs --test |
| changeset level | patch on @objectstack/spec + @objectstack/platform-objects |
read from the changeset on the head |
| model tier | 228 harness-stamped "model":"claude-opus-5", no other value |
subagent transcript grep |
Check Changeset |
SUCCESS on this head — the Clause-②: no body line did its job |
check-run |
⚠️ --pair 16941 reads exit 4, and it is a false positive this seat examined and overruled
C5: a widening tell (T2, "a new member of a closed set") at permission.form.ts:30. T2's own definition has no referent in that file — as const 0, z.enum 0, z.union 0, ] as 0, control defineForm 2 — no array gained a member (sections 4→4, fields 7→7), and the named line is a replaced property value, not an addition.
⛔ The declaration stays no. ⛔ The checker was not relaxed and the diff was not reshaped. The explanation is written into the claim comment as the checker's own remedy text names, and the direction is corroborated by two ratchets that did not fire: check:api-surface and check:authorable-surface, green with no regeneration required.
Scope held
#16929 filed rather than repaired: PageSchema.assignedProfiles is an authorable key named for the removed concept, and its alias map corrects an author writing profiles into the retired vocabulary. ⛔ Deliberately not fixed here — that repair is a metadata/schema change, which this dispatch fenced off. The fence worked as designed.
One bounded in-place fix, declared: the zh-CN group label 权限集 / 配置文件 → 权限集. Same defect class, same file already inside the claim's declared surface, no new verification surface. Accepted as in-scope and named rather than smuggled.
Gates
71 derived, 71 run, 0 UNRUN, reconciliation exit 0, 0 red. One NOT MEASURED (check:dual-build-cjs-loads, exit 3 PREREQUISITE NOT MET) declared and handed to CI. check:i18n drifted before and was regenerated with the repo's own tool (in sync (11 bundle(s)) after) — ⛔ not hand-edited.
Landing
⛔ Not enqueued yet — 32 names, 8 still running, 0 non-green. Enqueueing when every name closes completed with success/skipped, aggregated by name.
Generated by Claude Code
…d line stops reading as an addition The two tells the card reproduced fire on added LINES and could not see that a line replaced an equivalent one: PR #16941 (T2, a form `description:` prose rewrite on a file with no closed set in it) and PR #16968 (T1, a Zod key whose `.describe()` text grew, keys 32 -> 32). Neither moved an accept set, and the C5 row neither raised could be cleared except by declaring `Clause-②: yes` on a change that does not widen. `tellsInFile` now reads a REPLACEMENT BUDGET per change block, per tell kind: each removed line carrying a member or key of kind K buys one added line of kind K the right not to be reported, spent in patch order, so a block that adds more than it removed still reports the surplus with its own file:line. The unit is the change block, never the hunk, so an unrelated removal three context lines away cannot pay for a real addition. Openers and prose fragments pay for nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Fixes #16228
Clause-②: no
Both repairs are prose: a TSDoc comment, and a shipped form section description plus its three
translated leaves. No accept set moves, no export is added or removed, no key lands on a published
payload.
check:api-surfaceandcheck:authorable-surfaceare green with no regeneration, which isthat claim measured rather than asserted. 拉回已声明契约 ⇒ 常规档. The changeset is
patchfor bothpackages, matching that tier.
The dispatch fenced this: had the reading required a metadata or schema change — a registry key, a
form kind, a schema arm — this run was to stop and report. It did not require one. The one place
where such a change would be required is out of scope and filed separately, see 验收备注 below.
The first deliverable: the ADR-0090 reading
The card and triage agreed the repair could not be guessed, because two readings lead to opposite
edits, and the deciding evidence is ADR-0090 read against the current
permissionschema. Thatreading comes out decisively on the first branch:
profileis retired as a metadata KIND, notmerely as a registry key. Seven artefacts on this tree say so, and none says otherwise.
docs/adr/0090-permission-model-v2-concept-convergence.md, TL;DRisProfiledeleted, not deprecated).isProfileis deleted fromPermissionSetSchema— "removed, not deprecated (launch window)"; rationale 3 is conceptual, not mechanical: "A pure-additive model teaches in one sentence." UI consequence: "the profile badge/toggle in the permission matrix is removed".packages/spec/src/security/permission.zod.ts,PermissionSetSchemaheaderisProfiletombstoneisProfilewas removed by ADR-0090 D2 — there is no Profile concept." Shipped to authors at parse time.profilespointerprofilesis not a PermissionSet field (ADR-0090 D2: no Profile concept)."packages/spec/src/kernel/capability-metadata-kind.test.tsrole/profile/policy: not aMetadataTypeSchemakind, no registry entry, resolves no schema.packages/spec/src/system/metadata-form-registry.tsroleandprofilemetadata kinds were retired in the P1 wave… the profile concept is gone."An eighth, corroborating:
packages/spec/src/conversions/registry.tscarries an ADR-0087 conversionbook-audience-profile-to-permission-setthat migratesbook.audience.{ profile }to{ permissionSet }, summarised "(ADR-0090 D2/D9)" and markedretiredFromLoadPath: true. A conceptthat survived as a kind authors name would not be migrated out of a neighbouring schema's audience arm.
⇒ Triage's own conditional therefore resolves to its p2 branch: "
profileretired as a metadatakind ⇒ the docstring's first sentence goes and the shipped section description is wrong and
must be rewritten." Both edits are made here, and the section description's locale bundles with them.
Why the locales are not optional — measured, not assumed
The translated leaves would not have gone red or stale if left behind.
source-hash.tsrecords adigest only while a leaf is still a byte copy of its source; a real translation has no entry and is
LEGACY-TRUSTED, never reported stale, and
withSourceFallbacknever substitutes for it.metadataForms.*entriesmetadataForms.permission.*entrieszh-CN.source-hashes.generated.tsja-JP.source-hashes.generated.tses-ES.source-hashes.generated.tsThe left column is the positive control: the tables do carry
metadataFormsentries, so the zero onthe right is a reading and not a broken grep. ⇒ Fixing only the English would have left three
languages teaching the retired concept under a fully green build, with no mechanism reporting it.
Four locales carried the sentence (
en,zh-CN,ja-JP,es-ES); all four move here.Is this published text? Measured two ways, on the artefact that actually ships
a path-vs-
files[]match alone gives the wrong answer for this package pair.Half 1 — do the changed PATHS match
files[]? No, for either package.files[]@objectstack/specdist,json-schema,liveness,prompts,llms.txt,README.md,src/**/*.zod.ts,CHANGELOG.md,api-surface,spec-changes.jsonsrc/security/permission.form.ts*.form.ts, not*.zod.ts@objectstack/platform-objectsdist,README.md,CHANGELOG.mdsrc/apps/translations/*.generated.tssrcentry at allVerified against the published tarballs of
17.3.0, not against the glob: the publishedspectree contains 0
*.form.tsfiles (and 208*.zod.ts, the positive control —permission.zod.tsamong them, so the
findsees the tree it is scanning);platform-objectspublishes nosrc/.⇒ Stopping here would have concluded
skip-changeset. That conclusion is wrong.Half 2 — is the TEXT in the published artefact? Yes, in both, and in every locale. The carrier is
dist, which both packages ship and which is built from exactly these sources. Grepping the unpackedpublished tarballs (non-ASCII compared in esbuild's uppercase
\uXXXXspelling):17.3.0@objectstack/specdist/— the retired English sentence@objectstack/specdist/— untouched sibling description (positive control)@objectstack/platform-objectsdist/— retired English sentence@objectstack/platform-objectsdist/— retiredja-JPtranslation@objectstack/platform-objectsdist/— retiredes-EStranslation@objectstack/platform-objectsdist/— retiredzh-CNgroup label"权限集 / 配置文件"@objectstack/platform-objectsdist/— untouchedzh-CNleaf (positive control)⇒ Published text moves. A changeset is required,
patchfor both packages, matching theClause-②: notier. ⛔ Notskip-changeset.⭐ One probe returned 0 while its controls returned 6, and it is a real finding rather than a bad
grep: the retired
zh-CNdescription in this branch ("权限集叠加在配置文件之上…") is not in17.3.0— that release predates PR #16226 (merged 2026-09-06). Decoding the published bundle showszh-CNshipped an earlier spelling of the same retired concept:"权限集或配置文件标识". So thezh-CNleaf has taught the retired Profile concept in two successive spellings, and this is thefirst change to remove it rather than re-word it.
distprobe returned zero for every string including the untouched control, becauseesbuild escapes non-ASCII as uppercase
\uXXXXand the probe generated lowercase. Re-measured.es-ESprobe cannot be run whole:único/uniónare escaped mid-string, so an ASCII-onlyfragment (
se apilan sobre un perfil) is the only spelling that reads.The docs-drift check returned a null reading, not a clean one
The bot on this PR says "this run has no opinion about the docs" — 2 names were too generic to
anchor anything (single lowercase words). ⛔ That is the instrument reporting it was blind here, and
on this card it is blind to exactly the expensive half: user-facing text in four languages. It is
recorded as no-information, and the locale coverage above is what stands in its place.
The second defect
"The only flags are minimal (ADR-0090 D2 removed the Profile concept) so admins can see and toggle
it explicitly" named no flag and its
itreferred to nothing. It is replaced by a measuredstatement: the form surfaces no flag, because
isDefault(ADR-0090 D5) isPermissionSetSchema'sonly top-level boolean — verified, one
z.boolean()in the whole set body — and it records aboot-time binding hint, not a grant. The form's four sections hold
name,label,systemPermissions,objects,fields,tabPermissions,rowLevelSecurity.One further translated leaf, in the same shipped group
zh-CN rendered the
permissionform group label as"权限集 / 配置文件"— appending the retiredconcept to a source label that is plain
Permission Set(metadata-plugin.zod.ts).ja-JP(
"権限セット") andes-ES("Conjunto de permisos") already rendered the source faithfully.Repaired to
"权限集"in the same bounded change: same defect class as the card, same file already inthis claim's declared surface, same gate family (
check:i18n+metadata-forms-vocabulary.test.ts),no new verification surface, and the only recent claim on that file (PR #16226) merged 2026-09-06.
Leaving it would have shipped a group whose label still taught the concept its own description had
just stopped teaching.
Verification
Gates derived by name, not guessed:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands,re-derived after the changeset existed (65 → 71 families), each run, then reconciled with
--ran:pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date, noregeneration required (
check:docs,check:api-surface,check:authorable-surfaceamong them).pnpm check:i18n— drifted before (platform-objects, 1 bundle, theencopy of the source),regenerated with
node scripts/check-i18n-bundles.mjs --write,in sync (11 bundle(s))after.pnpm check:i18n-stale-fill—0 stale-fill leaf/leaves, 0 baselined.pnpm --filter @objectstack/spec test—Test Files 467 passed (467) · Tests 13063 passed (13063).pnpm --filter @objectstack/platform-objects test—Test Files 37 passed (37) · Tests 545 passed (545),including
metadata-forms-vocabulary.test.ts, the pin this card turns on.pnpm --filter @objectstack/spec typecheckandpnpm --filter @objectstack/platform-objects typecheck— clean.pnpm check:dual-build-cjs-loadsexits 3 here —PREREQUISITE NOT MET, seven unrelated packages have nodistin this container and it says in asmany words "This is NOT a pass: nothing was measured." It needs a whole-workspace build, which does
not fit one foreground call. CI builds the workspace and runs it. Read as neither red nor green.
Every heavy run went through
scripts/pm/os-verify-lock.sh; exit codes were captured before any pipeand the verdict read from each gate's own line.
验收备注
profiles:into it #16929 (class b, contract violation; no labels, no assignee, per the findingprotocol).
PageSchema.assignedProfilesis an authorable key on a published schema named forthe very concept D2 removed; its alias map maps
profiles:andassignedTo:onto it, so an authorwho writes
profileson a page is corrected into the retired vocabulary — two lines from wherePermissionSetSchemaanswers the same word with "no Profile concept". Two shipped guidance stringsteach it, and
page.form.ts'shelpText: 'Profiles that can access this page'is translated intoall four locales. ⛔ Deliberately not touched here: repairing it is a metadata/schema change,
which is exactly what this dispatch fenced off, and the three plausible repairs (rename, retarget
the aliases, remove) have different blast radii. That card is not addressed by this PR.
Final Profile/Profile Sourcein*.objects.generated.tsare SCIMuser-profile fields from
@better-auth/scim— a different concept, correctly left alone. Boundarycase recorded so the next reader does not re-open it. 承接者:[finding] PageSchema.assignedProfiles is an authorable key named for the concept ADR-0090 D2 removed, and the alias map corrects an authored
profiles:into it #16929 的评审者。enmetadata-forms bundle is regenerated from source on every extract, whilethe three translated bundles are merge-only. That asymmetry is documented in
i18n-extract.config.tsand behaved exactly as documented here; no defect. 承接者:无。Generated by Claude Code